Skip to content

build member tables of instantiated classes/interfaces lazily - #64475

Draft
Max Schwenk (maschwenk) wants to merge 10 commits into
microsoft:mainfrom
maschwenk:perf/declared-shape-queries
Draft

Max Schwenk (maschwenk) wants to merge 10 commits into
microsoft:mainfrom
maschwenk:perf/declared-shape-queries

Conversation

@maschwenk

@maschwenk Max Schwenk (maschwenk) commented Sep 27, 2026 •

Copy link
Copy Markdown

Part of this issue (the intersection half landed separately, see related PRs at the bottom):

looking up one property on an instantiated class or interface builds the whole member table right now. every declared member gets instantiated and every inherited one gets merged in, even though most of them never get used. a few shape checks (isWeakType, getSingleSignature, isStringIndexSignatureOnlyType, isFunctionObjectType) do the same thing just to count stuff

this gives such a reference a lazy member table instead. setting it up does what resolveObjectTypeMembers does in the same order, except creating the member symbols. signatures and index infos get instantiated and inherited as usual, through a helper resolveObjectTypeMembers now shares, so getSignaturesOfStructuredType and getIndexInfosOfStructuredType can answer from the table. member lookups only instantiate the member that was asked for, and if the full table gets built later it reuses whatever was already handed out, so symbol identity doesnt change

the shape checks read through those accessors plus getMemberOfStructuredType, hasPropertiesOfStructuredType and everyPropertyOfStructuredType, so each check is still written once. the lazy lookups alone dont save anything since the shape checks would build the full table right away, those are what make it pay off

whether instantiateSymbol hands back the original symbol depends on what's been resolved so far, so the table records that when it's set up. now that resolveObjectTypeMembers is idempotent again, that's the only thing it has to copy. while a table is being set up the type just resolves its members the normal way

history: the first version (up to af2719b) had a separate lazy copy of each shape check, cdac4e3 reworked it to go through the accessors, after merging main 39ce30c drops the half built member handling that the idempotency change made unnecessary, d40b4bd trims the lookup (no memo, reuses getPropertyOfTypeEx for bases), and the last two commits cut the tests down to one and trim comments. the code is +254/−37 now (was ~570), the rest is one 74 line test and its generated baselines

this PR vs main at 0681ef7 (so with both related PRs below), typescript-benchmarking, median of 3:

heap, 4 checkers peak rss, 4 checkers check, 4 checkers heap, single check, single
vscode −9.1% −3.0% −0.5% −8.0% −1.8%
xstate-main −9.8% −6.8% −2.1% −7.4% −1.0%
webpack −9.0% −4.3% −5.3% −6.4% −2.5%
Compiler −2.2% −6.1% −4.3% −1.7% −0.6%
Compiler-Unions −2.3% −2.8% +1.8% −2.1% −0.3%
mui-docs −1.5% −0.4% +0.5% −1.6% +1.7%

on our 37k-file program, also on 0681ef7: heap 19.1 → 15.3 GiB with 4 checkers (−20%) and 12.6 → 10.5 GiB single threaded (−17%), check time −6% single threaded. same diagnostics on every run. raw results and scripts: https://gist.github.com/maschwenk/c83d9185c6961b6fb43d7d071ef39cf6

full go suite passes (also multiple checkers and -race), lint and format are clean. added instantiatedReferenceLazyMembers.ts with baselines generated on unmodified main, so it pins that the output didnt change

  • associated issue (linked at the top)
  • up to date with main
  • tests pass (go test ./... in tsc, same thing hereby test runs)
  • hereby lint
  • hereby check:format
  • new tests

related:

used claude code to help write this, ive reviewed it

Looking up one property of an instantiated class or interface reference
(`expect(x).toBe`, `schema.optional`, `arr.map`) resolved all of its
members: every declared member was instantiated and every inherited member
merged, although most of those symbols are never used.

The first lookup on such a reference now prepares a lazy member table
instead. Preparing does everything resolveObjectTypeMembers does except
create the member symbols, in the same order, so everything that is
resolved along the way (signatures, index infos, base types and their
members) is resolved exactly as before. It also records which declared
members instantiateSymbol would return as they are at that point, since
that depends on what has been resolved. Lookups then instantiate only the
requested member, walking the prepared base types in addInheritedMembers
order, and resolving the members in full later reuses the symbols already
handed out. If preparing leads back to the reference, it exposes the same
partial members resolveObjectTypeMembers would, and is resolved in full
from then on.

A lookup of a missing name answers the Object/Function augmentation from
the number of call and construct signatures, counted from the declared
signatures and those of the prepared base types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bers

isWeakType, getSingleSignature, getSignaturesOfStructuredType,
getIndexInfosOfStructuredType, isEmptyObjectType, isFunctionObjectType and
isStringIndexSignatureOnlyType resolved all members of an instantiated
class or interface reference just to count its properties, signatures or
index infos, or to see whether its properties are optional.

For a reference with a lazy member table these are now answered from the
table. Signatures are counted from the instantiated declared signatures and
those of the prepared base types. Whether there are properties, and whether
all of them are optional, follows from the declared members (which have the
same flags as their instantiations) and the properties of the base types,
merged as in addInheritedMembers. Index infos are merged from the
instantiated declared index infos and those of the base types, as in
resolveObjectTypeMembers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@typescript-automation typescript-automation Bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Sep 27, 2026
@maschwenk Max Schwenk (maschwenk) changed the title Resolve members of instantiated class and interface references lazily Build member tables of instantiated classes and interfaces lazily Sep 27, 2026
@maschwenk Max Schwenk (maschwenk) changed the title Build member tables of instantiated classes and interfaces lazily build member tables of instantiated classes/interfaces lazily Sep 27, 2026
Resolving the members of a lazily prepared reference in full stored every
instantiated member in the table's memo, which is discarded right after,
and checked each member against the list of members that instantiate to
themselves with a linear scan. The list is now sorted and binary searched,
and members are only memoized when looked up individually.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Property and index info lookups are hot, and nearly always see types whose
members are resolved. They now check that inline before calling into the
lazy member table code, which on material-ui's docs project took 2-3% of
check time on its own. The lazy part of getPropertyOfTypeEx moves to
getPropertyOfObjectTypeLazily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each shape check (isWeakType, getSingleSignature,
isStringIndexSignatureOnlyType, isFunctionObjectType, getPropertyOfTypeEx)
had a second, lazy version of its condition. Now the lazy table stores the
reference's full signatures and index infos, inherited through a helper
resolveObjectTypeMembers shares, and getSignaturesOfStructuredType and
getIndexInfosOfStructuredType answer from it. The checks read through those
accessors and getMemberOfStructuredType, hasPropertiesOfStructuredType and
everyPropertyOfStructuredType, so each condition is written once.

The separate shape summary goes away, as do the hooks in
getPropertyOfObjectType and isEmptyObjectType, which saved no memory, and
the code moves into checker.go.

This also removes a crash path: isWeakType read the table's shape, called
getIndexInfosOfStructuredType, and read the shape again, which would find
no table if that call had resolved the type in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ahejlsberg

Copy link
Copy Markdown
Member

Max Schwenk (@maschwenk) I definitely want to land #64499, really nice savings from that one. Once it is in, let's see how much extra savings we can get from this one. I'm a bit concerned with adding ~500 lines of code to member resolution.

@typescript-automation typescript-automation Bot added For Milestone Bug PRs that fix a bug with a specific milestone and removed For Uncommitted Bug PR for untriaged, rejected, closed or missing bug labels Sep 28, 2026
resolveObjectTypeMembers no longer exposes a type's declared members while
its base types resolve, so the lazy table doesn't need to either. A table is
now either being prepared, during which the type resolves its members as
usual, or ready; the materialized state, the count of inherited base types
and the partial lookup paths go away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@typescript-automation typescript-automation Bot added For Uncommitted Bug PR for untriaged, rejected, closed or missing bug and removed For Milestone Bug PRs that fix a bug with a specific milestone labels Sep 29, 2026
Inherited properties are looked up with getPropertyOfTypeEx instead of a
separate helper, lookups are no longer memoized (the memo cost more memory
than it saved, with no change in check time), and a few one-use helpers are
inlined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maschwenk

Max Schwenk (maschwenk) commented Sep 29, 2026 •

Copy link
Copy Markdown
Author

Anders Hejlsberg (@ahejlsberg) thanks for landing this!

merged main in, and with this one in i could drop all the half built member handling and trim the rest:

so the code is +254/−37 now (was ~570), plus one test and its generated baselines. on top of current main:

  • vscode, xstate, webpack: ~9-10% less heap with 4 checkers (6-8% single threaded), check time 1-5% faster
  • our 37k-file program: heap 19.1 → 15.3 GiB with 4 checkers (−20%), 12.6 → 10.5 GiB single threaded, check −6%

mui-docs is about flat on memory and ~2% slower. full table is in the description

also noticed the new never-reduction check walks the counts map, so the order it builds props in is random and the same binary does slightly different work run to run (vscode symbol count 8557485 / 8557513 / 8557486, in first-seen order its 8557373 every time). diagnostics were the same in everything i ran, but figured id mention it. sent a fix:

One test covering what the two did that the existing suite doesn't: member
lookups with redeclared and private members, base types whose shape depends
on the instantiation (weak types, bind, string index signatures, merged
signatures), an interface extending a type parameter, a recursive base type
and function narrowing. Baselines are generated on unmodified main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

For Uncommitted Bug PR for untriaged, rejected, closed or missing bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants